All files / src/routes/review/[id] +page.server.ts

89.74% Statements 70/78
83.58% Branches 56/67
75% Functions 6/8
89.04% Lines 65/73

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368                                                                                                                            17x               7x   7x 7x 1x     6x     6x           6x         6x 1x                       5x   5x 1x       1x               1x                       1x             1x                   1x                                               5x 5x   5x 4x           4x       4x                     4x 2x         3x     2x       2x               5x   7x                                         17x                 4x 4x   3x   3x             3x   1x     2x   1x                               6x 6x   5x 5x       5x         5x       5x               6x   6x 7x 7x     7x 7x   7x   5x                                 5x             5x 1x     4x 1x           3x 2x 2x 1x       2x      
import { error, fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
 
// ---------------------------------------------------------------------------
// Review view types.
//
// The block-review surface surfaces substitution/addition activity within
// the reviewed block, per spec reviews.allium:163-196
// (PromptBlockReviewSubstitutionSignals).
//
// NOTE — SubstitutionDecisionUI is GATED per #257.
// The record_manual_substitution RPC and the substitute-vs-add flow depend
// on a reviewable manual workout, which requires the manual workout authoring
// loop (picking exercises, prescribing sets). That authoring UX is #257's
// deliverable. No runnable substitute-vs-add UI is wired up here — only the
// data-driven block-review reason surfacing is implemented, which renders
// defensively when superseded/manual workouts exist in the block.
// ---------------------------------------------------------------------------
 
export interface SubstitutedWorkoutView {
	id: string;
	scheduled_date: string;
	session_type: string;
	/** The manual workout that superseded this one (if any). */
	superseded_by: string | null;
}
 
export interface ManualWorkoutView {
	id: string;
	scheduled_date: string;
	session_type: string;
}
 
export interface BlockSubstitutionActivity {
	substituted: SubstitutedWorkoutView[];
	additions: ManualWorkoutView[];
}
 
// ---------------------------------------------------------------------------
// Workout-review view types (spec reviews.allium:675-724, WorkoutReview surface)
//
// A post-workout review's standard prompts are AI-generated asynchronously
// (pg_net → generate-review-questions edge fn, with a static fallback set), so
// a review can be in `pending` state with zero prompts for a brief window after
// the workout completes. The client polls (invalidate('review:prompts')) while
// `promptsPending` is true until the prompts land.
// ---------------------------------------------------------------------------
 
export interface ReviewPromptView {
	id: string;
	question: string;
	/** Closed-question options, or null for a free-text prompt. */
	options: string[] | null;
	order: number;
}
 
export interface ReviewResponseView {
	review_prompt_id: string;
	selected_option: string | null;
	free_text: string | null;
}
 
export const load: PageServerLoad = async ({
	params,
	depends,
	locals: { supabase, safeGetSession },
}) => {
	// Custom invalidation key — the client polls invalidate('review:prompts')
	// to re-run this load (only) while a workout-review's AI prompts are still
	// being generated, mirroring the dashboard:cycle-status poll precedent.
	depends('review:prompts');
 
	const { user } = await safeGetSession();
	if (!user) {
		redirect(303, '/login');
	}
 
	const reviewId = params.id;
 
	// Load the review row. RLS scopes this to the authenticated athlete.
	const { data: review, error: reviewErr } = await supabase
		.from('reviews')
		.select('id, scope, status, block_id, mesocycle_id, workout_id, created_at')
		.eq('id', reviewId)
		.maybeSingle();
 
	Iif (reviewErr) {
		error(500, 'Failed to load review');
	}
 
	// RLS null → 404 (cross-athlete or non-existent).
	if (!review) {
		error(404, 'Review not found');
	}
 
	// -------------------------------------------------------------------------
	// Block-review substitution signals
	// (spec: PromptBlockReviewSubstitutionSignals, reviews.allium:163-196)
	//
	// Only applies to block_review scope. Renders defensively — no live data
	// until #257's manual workout flow ships (expected). When the block has
	// substituted or manual workouts, we surface them here.
	// -------------------------------------------------------------------------
 
	let blockSubstitutionActivity: BlockSubstitutionActivity | null = null;
 
	if (review.scope === 'block_review' && review.block_id) {
		const blockId = review.block_id;
 
		// Fetch workouts in this block that were substituted (superseded, with a
		// superseded_by link pointing to the manual workout that replaced them).
		const { data: supersededRows, error: subsErr } = await supabase
			.from('workouts')
			.select('id, scheduled_date, session_type, superseded_by')
			.eq('block_id', blockId)
			.eq('status', 'superseded')
			.not('superseded_by', 'is', null)
			.order('scheduled_date', { ascending: true });
 
		Iif (subsErr) {
			// Non-fatal — degrade gracefully; block review still functions.
			console.error({
				event: 'block_review_substituted_load_failed',
				code: subsErr.code,
				message: subsErr.message,
				review_id: reviewId,
				block_id: blockId,
			});
		}
 
		// Fetch manual workouts in this block (athlete-added sessions, source='manual').
		const { data: manualRows, error: manualErr } = await supabase
			.from('workouts')
			.select('id, scheduled_date, session_type')
			.eq('block_id', blockId)
			.eq('source', 'manual')
			.order('scheduled_date', { ascending: true });
 
		Iif (manualErr) {
			console.error({
				event: 'block_review_manual_load_failed',
				code: manualErr.code,
				message: manualErr.message,
				review_id: reviewId,
				block_id: blockId,
			});
		}
 
		blockSubstitutionActivity = {
			substituted: (supersededRows ?? []).map((r) => ({
				id: r.id,
				scheduled_date: r.scheduled_date,
				session_type: r.session_type,
				superseded_by: r.superseded_by,
			})),
			additions: (manualRows ?? []).map((r) => ({
				id: r.id,
				scheduled_date: r.scheduled_date,
				session_type: r.session_type,
			})),
		};
	}
 
	// -------------------------------------------------------------------------
	// Workout-review prompts + responses (spec: WorkoutReview surface)
	//
	// Standard prompts are AI-generated (or fall back to a static set) after the
	// review is created, so they may be absent for a brief window while the
	// review sits in `pending`. We surface `promptsPending` so the client can
	// render a "preparing your questions" state and poll until they arrive.
	// -------------------------------------------------------------------------
 
	let prompts: ReviewPromptView[] = [];
	let responses: ReviewResponseView[] = [];
 
	if (review.scope === 'workout_review') {
		const { data: promptRows, error: promptsErr } = await supabase
			.from('review_prompts')
			.select('id, question, options, "order"')
			.eq('review_id', reviewId)
			.order('"order"', { ascending: true });
 
		Iif (promptsErr) {
			error(500, 'Failed to load review prompts');
		}
 
		prompts = (promptRows ?? []).map((p) => ({
			id: p.id,
			question: p.question,
			options: (p.options as unknown as string[] | null) ?? null,
			order: p.order,
		}));
 
		// Existing responses (if the athlete has answered any). review_responses
		// link to a review only via review_prompt_id (there is NO review_id
		// column), so we scope by the prompt ids we just loaded. RLS enforces
		// athlete ownership through the prompt → review join independently.
		if (prompts.length > 0) {
			const { data: responseRows, error: responsesErr } = await supabase
				.from('review_responses')
				.select('review_prompt_id, selected_option, free_text')
				.in(
					'review_prompt_id',
					prompts.map((p) => p.id),
				);
 
			Iif (responsesErr) {
				error(500, 'Failed to load review responses');
			}
 
			responses = (responseRows ?? []).map((r) => ({
				review_prompt_id: r.review_prompt_id,
				selected_option: r.selected_option,
				free_text: r.free_text,
			}));
		}
	}
 
	const promptsPending = review.scope === 'workout_review' && review.status === 'pending' && prompts.length === 0;
 
	return {
		review,
		blockSubstitutionActivity,
		prompts,
		responses,
		promptsPending,
	};
};
 
// ---------------------------------------------------------------------------
// Form actions — start, submit (spec reviews.allium StartReview / CompleteReview)
//
// All writes use the request-scoped `locals.supabase` so RLS gates every
// mutation by `auth.uid()`, and the DB `check_review_status_transition` trigger
// enforces the pending → in_progress → completed state machine.
//
// Negative-authz (P7): an RLS-filtered UPDATE affects 0 rows and returns
// `data: null, error: null` (no raise). We chain `.select('id').maybeSingle()`
// and treat `null` as denied, mirroring the workout `complete` action.
// ---------------------------------------------------------------------------
 
export const actions: Actions = {
	// -------------------------------------------------------------------------
	// start: pending → in_progress (StartReview, reviews.allium:396-411)
	//
	// The explicit gate the athlete crosses when they open the review to begin
	// answering. Guarded by check_review_status_transition (rejects e.g.
	// completed → in_progress) and RLS (cross-athlete → 0 rows → null).
	// -------------------------------------------------------------------------
	start: async ({ params, locals: { supabase, safeGetSession } }) => {
		const { user } = await safeGetSession();
		if (!user) redirect(303, '/login');
 
		const reviewId = params.id;
 
		const { data: updated, error: updateErr } = await supabase
			.from('reviews')
			.update({ status: 'in_progress' })
			.eq('id', reviewId)
			.select('id')
			.maybeSingle();
 
		if (updateErr) {
			// Transition trigger rejected (e.g. already completed) or DB error.
			return fail(409, { error: 'Could not start review — please refresh and try again.' });
		}
 
		if (!updated) {
			// RLS silently filtered the UPDATE (cross-athlete or non-existent).
			return fail(409, { error: 'Could not start review — please refresh and try again.' });
		}
 
		// SvelteKit re-runs load; the review renders in_progress with its prompts.
	},
 
	// -------------------------------------------------------------------------
	// submit: record responses, then in_progress → completed (CompleteReview,
	// reviews.allium:413-427)
	//
	// For each answered prompt, INSERT a review_responses row. review_responses
	// link ONLY via review_prompt_id (no review_id column). Then transition the
	// review to completed (ai_summary left NULL — #17c defers summary
	// generation) and redirect to the dashboard, closing the J02 loop.
	// -------------------------------------------------------------------------
	submit: async ({ params, request, locals: { supabase, safeGetSession } }) => {
		const { user } = await safeGetSession();
		if (!user) redirect(303, '/login');
 
		const reviewId = params.id;
		const formData = await request.formData();
 
		// Re-load the prompt ids for this review (RLS-scoped) so we only accept
		// responses to prompts that genuinely belong to the caller's review.
		const { data: promptRows, error: promptsErr } = await supabase
			.from('review_prompts')
			.select('id, options')
			.eq('review_id', reviewId);
 
		Iif (promptsErr) {
			return fail(500, { error: 'Could not load review — please try again.' });
		}
 
		const prompts = promptRows ?? [];
 
		// Build one response row per answered prompt. A prompt is "answered" when
		// the athlete picked an option (closed prompt) or typed free text.
		const responseRows: {
			review_prompt_id: string;
			selected_option: string | null;
			free_text: string | null;
		}[] = [];
 
		for (const prompt of prompts) {
			const selectedRaw = formData.get(`option_${prompt.id}`);
			const textRaw = formData.get(`text_${prompt.id}`);
 
			const selected =
				typeof selectedRaw === 'string' && selectedRaw.trim() !== '' ? selectedRaw : null;
			const freeText = typeof textRaw === 'string' && textRaw.trim() !== '' ? textRaw.trim() : null;
 
			if (selected === null && freeText === null) continue;
 
			responseRows.push({
				review_prompt_id: prompt.id,
				selected_option: selected,
				free_text: freeText,
			});
		}
 
		// CompleteReview: in_progress → completed FIRST, so the transition gates
		// the response write. ai_summary stays NULL (#17c). The order matters:
		// a stray/replayed `submit` against a still-`pending` review (reachable
		// only by direct POST, not via the UI) would otherwise write orphan
		// responses before the transition failed. Doing the guarded UPDATE first
		// means a review that is not `in_progress` (pending → completed is not a
		// legal transition; the trigger raises) fails here BEFORE any response is
		// inserted. `.select('id').maybeSingle()` catches both the RLS silent-no-op
		// (cross-athlete → 0 rows → null) and the transition-trigger rejection
		// (UPDATE error), preserving the negative-authz fail(409) semantics.
		const { data: completed, error: completeErr } = await supabase
			.from('reviews')
			.update({ status: 'completed' })
			.eq('id', reviewId)
			.select('id')
			.maybeSingle();
 
		if (completeErr) {
			return fail(409, { error: 'Could not complete review — please refresh and try again.' });
		}
 
		if (!completed) {
			return fail(409, { error: 'Could not complete review — please refresh and try again.' });
		}
 
		// Only now — with the review confirmed transitioned to completed for this
		// athlete — record the responses. No orphan rows can precede a failed
		// transition.
		if (responseRows.length > 0) {
			const { error: insertErr } = await supabase.from('review_responses').insert(responseRows);
			if (insertErr) {
				return fail(500, { error: 'Could not save your responses — please try again.' });
			}
		}
 
		redirect(303, '/dashboard');
	},
};